diff options
Diffstat (limited to 'frontend/src/routes/status/[id]')
| -rw-r--r-- | frontend/src/routes/status/[id]/+page.svelte | 278 | ||||
| -rw-r--r-- | frontend/src/routes/status/[id]/+page.ts | 22 |
2 files changed, 300 insertions, 0 deletions
diff --git a/frontend/src/routes/status/[id]/+page.svelte b/frontend/src/routes/status/[id]/+page.svelte new file mode 100644 index 0000000..afc59a2 --- /dev/null +++ b/frontend/src/routes/status/[id]/+page.svelte @@ -0,0 +1,278 @@ +<script lang="ts"> + import type { PageData } from './$types'; + + let { data }: { data: PageData } = $props(); + + let svc = $derived(data.service); + let history = $derived(data.history ?? []); + let stats = $derived(data.stats); + + let isUp = $derived(svc?.last_heartbeat?.is_up ?? null); + let expandedError: number | null = $state(null); + + let chartPad = { t: 8, b: 20, l: 40, r: 12 }; + let chartW = $derived(Math.max(history.length * 32, 300)); + let chartH = 200; + let plotW = $derived(chartW - chartPad.l - chartPad.r); + let plotH = $derived(chartH - chartPad.t - chartPad.b); + let chartMax = $derived(Math.max(...history.map((h: any) => h.response_time_ms), 100)); + + let dots = $derived( + history.map((h: any, i: number) => { + const x = history.length === 1 + ? chartPad.l + plotW / 2 + : chartPad.l + (i / (history.length - 1)) * plotW; + const y = chartPad.t + plotH - ((h.response_time_ms - 0) / (chartMax || 1)) * plotH; + return { x, y, ...h }; + }) + ); + + let yTicks = $derived.by(() => { + if (chartMax <= 0) return []; + const n = 4; + return Array.from({ length: n + 1 }, (_, i) => { + const val = (chartMax / n) * i; + const y = chartPad.t + plotH - (val / chartMax) * plotH; + return { val: Math.round(val), y }; + }); + }); +</script> + +<svelte:head> + <title>{svc?.name ?? 'Status'} — YAUM</title> +</svelte:head> + +<div class="mx-auto max-w-4xl"> + {#if !svc} + <div class="rounded-xl border border-[var(--border-color)] p-12 text-center"> + <div class="mx-auto mb-4 h-16 w-16 rounded-2xl bg-[var(--bg-secondary)] p-4"> + <svg class="h-full w-full text-[var(--text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" /> + </svg> + </div> + <p class="text-sm font-medium text-[var(--text-secondary)]">Serviço não encontrado</p> + <p class="mt-1 text-xs text-[var(--text-muted)]"> + <a href="/" class="underline transition-colors hover:text-white">← Voltar ao início</a> + </p> + </div> + {:else} + <!-- Back link --> + <div class="mb-6"> + <a href="/" class="text-xs text-[var(--text-muted)] underline transition-colors hover:text-white"> + ← Voltar ao Dashboard + </a> + </div> + + <!-- Hero status --> + <div + class="mb-8 overflow-hidden rounded-2xl border p-8 text-center transition-all" + style="border-color: {isUp === true ? 'rgba(34,240,106,0.3)' : isUp === false ? 'rgba(255,64,96,0.3)' : 'var(--border-color)'}; background-color: {isUp === true ? 'rgba(34,240,106,0.04)' : isUp === false ? 'rgba(255,64,96,0.04)' : 'var(--bg-card)'}" + > + {#if isUp === null} + <div class="mb-2 h-4 w-4 animate-pulse rounded-full bg-[var(--text-muted)] mx-auto"></div> + <p class="text-sm font-semibold uppercase tracking-widest text-[var(--text-muted)]">Aguardando verificação</p> + {:else} + <div + class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full" + style="background-color: {isUp ? 'rgba(34,240,106,0.15)' : 'rgba(255,64,96,0.15)'}" + > + {#if isUp} + <svg class="h-8 w-8" style="color: var(--green)" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /> + </svg> + {:else} + <svg class="h-8 w-8" style="color: var(--red)" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> + </svg> + {/if} + </div> + <h1 + class="text-3xl font-bold tracking-tight" + style="color: {isUp ? 'var(--green)' : 'var(--red)'}" + > + {isUp ? 'Operando Normalmente' : 'Fora do Ar'} + </h1> + {/if} + + <div class="mt-4"> + <h2 class="text-xl font-semibold text-white">{svc.name}</h2> + <p class="mt-1 font-mono text-sm text-[var(--text-secondary)]">{svc.url}</p> + </div> + + {#if stats} + <div class="mt-6 inline-flex items-center gap-6 rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/30 px-6 py-3"> + <div class="text-center"> + <p class="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">SLA 30 dias</p> + <p + class="text-xl font-bold tabular-nums" + style="color: {stats.uptime_30d >= 99 ? 'var(--green)' : stats.uptime_30d >= 95 ? '#facc15' : 'var(--red)'}" + > + {stats.uptime_30d.toFixed(2)}% + </p> + </div> + <div class="h-8 w-px bg-[var(--border-color)]"></div> + <div class="text-center"> + <p class="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Latência Média</p> + <p class="text-xl font-bold tabular-nums text-white"> + {stats.avg_response_ms_30d.toFixed(0)}<span class="text-sm font-normal text-[var(--text-secondary)]">ms</span> + </p> + </div> + <div class="h-8 w-px bg-[var(--border-color)]"></div> + <div class="text-center"> + <p class="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Total de Checks</p> + <p class="text-xl font-bold tabular-nums text-white">{stats.total_checks_30d}</p> + </div> + </div> + {/if} + </div> + + <div class="grid gap-6 lg:grid-cols-5"> + <!-- SLA Breakdown --> + {#if stats} + <div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5 lg:col-span-2"> + <h2 class="mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]"> + Uptime + </h2> + <div class="space-y-2.5"> + {#each [ + { label: 'Últimas 24h', uptime: stats.uptime_24h, checks: stats.total_checks_24h, avg: stats.avg_response_ms_24h }, + { label: 'Últimos 7 dias', uptime: stats.uptime_7d, checks: stats.total_checks_7d, avg: stats.avg_response_ms_7d }, + { label: 'Últimos 30 dias', uptime: stats.uptime_30d, checks: stats.total_checks_30d, avg: stats.avg_response_ms_30d } + ] as item} + <div class="rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)]/20 p-3"> + <div class="mb-1 flex items-center justify-between"> + <span class="text-[10px] font-medium uppercase tracking-wider text-[var(--text-muted)]">{item.label}</span> + <span class="font-mono text-[10px] text-[var(--text-muted)]">{item.checks} checks</span> + </div> + <div class="flex items-baseline gap-3"> + <span + class="text-base font-bold tabular-nums" + style="color: {item.uptime >= 99 ? 'var(--green)' : item.uptime >= 95 ? '#facc15' : 'var(--red)'}" + > + {item.uptime.toFixed(2)}% + </span> + <span class="font-mono text-[10px] text-[var(--text-muted)]">{item.avg.toFixed(0)}ms méd.</span> + </div> + </div> + {/each} + </div> + </div> + {/if} + + <!-- Latency chart --> + <div + class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5" + class:lg:col-span-3={!!stats} + class:lg:col-span-5={!stats} + > + <h2 class="mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]"> + Tempo de Resposta (ms) + </h2> + <div class="relative h-48"> + {#if history.length === 0} + <div class="flex h-full items-center justify-center text-xs text-[var(--text-muted)]"> + Sem dados ainda + </div> + {:else if history.length === 1} + <div class="flex h-full flex-col items-center justify-center gap-1"> + <span + class="text-3xl font-bold tabular-nums" + style="color: {history[0].is_up ? 'var(--green)' : 'var(--red)'}" + > + {history[0].response_time_ms} + <span class="text-base font-normal text-[var(--text-secondary)]">ms</span> + </span> + <span class="text-xs text-[var(--text-muted)]"> + {history[0].is_up ? 'Online' : 'Offline'} — {history[0].status_code || 'timeout'} + </span> + </div> + {:else} + <svg class="h-full w-full" viewBox="0 0 {chartW} {chartH}" preserveAspectRatio="none"> + {#each yTicks as tick} + <line + x1={chartPad.l} y1={tick.y} x2={chartW - chartPad.r} y2={tick.y} + stroke="var(--border-color)" stroke-width="1" stroke-dasharray="3,3" + /> + <text x={chartPad.l - 6} y={tick.y + 3} text-anchor="end" fill="var(--text-muted)" font-size="9">{tick.val}</text> + {/each} + <polyline + points={dots.map((d: any) => `${d.x},${d.y}`).join(' ')} + fill="none" stroke="var(--green)" stroke-width="2" + stroke-linecap="round" stroke-linejoin="round" + /> + {#each dots as d} + <circle cx={d.x} cy={d.y} r="3.5" fill={d.is_up ? 'var(--green)' : 'var(--red)'} /> + {/each} + </svg> + {/if} + </div> + <div class="mt-2 flex justify-between text-[10px] text-[var(--text-muted)]"> + <span>{history[0]?.tested_at ? new Date(history[0].tested_at).toLocaleTimeString() : ''}</span> + <span>{history[history.length - 1]?.tested_at ? new Date(history[history.length - 1].tested_at).toLocaleTimeString() : ''}</span> + </div> + </div> + </div> + + <!-- History table --> + <div class="mt-6 rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5"> + <h2 class="mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]"> + Histórico de Verificações + </h2> + {#if history.length === 0} + <p class="py-8 text-center text-xs text-[var(--text-muted)]">Nenhuma verificação registrada</p> + {:else} + <div class="overflow-x-auto"> + <table class="w-full text-left text-xs"> + <thead> + <tr class="border-b border-[var(--border-color)]"> + <th class="pb-2 pr-4 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Horário</th> + <th class="pb-2 pr-4 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Status</th> + <th class="pb-2 pr-4 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Código</th> + <th class="pb-2 pr-4 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Latência</th> + <th class="pb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Erro</th> + </tr> + </thead> + <tbody> + {#each [...history].reverse() as h} + <tr class="border-b border-[var(--border-color)]/50 transition-colors hover:bg-[var(--bg-secondary)]/20"> + <td class="py-2 pr-4 font-mono text-[var(--text-muted)]"> + {new Date(h.tested_at).toLocaleString('pt-BR')} + </td> + <td class="py-2 pr-4"> + <span + class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold" + style="background-color: {h.is_up ? 'rgba(34,240,106,0.1)' : 'rgba(255,64,96,0.1)'}; color: {h.is_up ? 'var(--green)' : 'var(--red)'}" + > + {h.is_up ? 'UP' : 'DOWN'} + </span> + </td> + <td class="py-2 pr-4 font-mono text-[var(--text-secondary)]"> + {h.status_code > 0 ? h.status_code : '—'} + </td> + <td class="py-2 pr-4 font-mono text-[var(--text-secondary)]"> + {h.response_time_ms}ms + </td> + <td class="py-2 font-mono text-[var(--text-muted)]"> + {#if h.error_message} + <button + onclick={() => (expandedError = expandedError === h.id ? null : h.id)} + class="text-[10px] text-[var(--red)] underline underline-offset-2 transition-colors hover:opacity-70" + > + {expandedError === h.id ? '▲ erro' : '▼ erro'} + </button> + {#if expandedError === h.id} + <pre class="mt-1 break-all text-[11px] leading-relaxed">{(h as any).error_message}</pre> + {/if} + {:else} + — + {/if} + </td> + </tr> + {/each} + </tbody> + </table> + </div> + {/if} + </div> + {/if} +</div> diff --git a/frontend/src/routes/status/[id]/+page.ts b/frontend/src/routes/status/[id]/+page.ts new file mode 100644 index 0000000..4351aae --- /dev/null +++ b/frontend/src/routes/status/[id]/+page.ts @@ -0,0 +1,22 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ params, fetch }) => { + const id = params.id; + + const [servicesRes, historyRes, statsRes] = await Promise.all([ + fetch(`http://localhost:8080/api/services`).catch(() => null), + fetch(`http://localhost:8080/api/services/${id}/history`).catch(() => null), + fetch(`http://localhost:8080/api/services/${id}/stats`).catch(() => null) + ]); + + const services = servicesRes?.ok ? await servicesRes.json() : []; + const svc = services.find((s: any) => s.id === Number(id)); + const history = historyRes?.ok ? await historyRes.json() : []; + const stats = statsRes?.ok ? await statsRes.json() : null; + + return { + service: svc ?? null, + history, + stats + }; +}; |